| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439 |
- // @ts-nocheck
- import { zodResolver } from '@hookform/resolvers/zod'
- import { useParams } from 'common'
- import { isEqual } from 'lodash'
- import { AlertCircle, Book, Check } from 'lucide-react'
- import { useRouter } from 'next/router'
- import { useEffect, useId, useMemo, useState } from 'react'
- import { useForm } from 'react-hook-form'
- import { toast } from 'sonner'
- import {
- AiIconAnimation,
- Button,
- cn,
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
- Form,
- FormControl,
- FormField,
- FormItem,
- Input,
- Label,
- Popover,
- PopoverContent,
- PopoverTrigger,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- } from 'ui'
- import * as z from 'zod'
- import { EDGE_FUNCTION_TEMPLATES } from '@/components/interfaces/Functions/Functions.templates'
- import { DefaultLayout } from '@/components/layouts/DefaultLayout'
- import EdgeFunctionsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout'
- import { PageLayout } from '@/components/layouts/PageLayout/PageLayout'
- import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
- import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
- import { FileExplorerAndEditor } from '@/components/ui/FileExplorerAndEditor'
- import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types'
- import { useEdgeFunctionDeployMutation } from '@/data/edge-functions/edge-functions-deploy-mutation'
- import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
- import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { BASE_PATH } from '@/lib/constants'
- import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
- import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
- // Array of adjectives and nouns for random function name generation
- const ADJECTIVES = [
- 'quick',
- 'clever',
- 'bright',
- 'swift',
- 'rapid',
- 'smart',
- 'smooth',
- 'dynamic',
- 'super',
- 'hyper',
- ]
- const NOUNS = [
- 'function',
- 'handler',
- 'processor',
- 'responder',
- 'worker',
- 'service',
- 'api',
- 'endpoint',
- 'action',
- 'task',
- ]
- // Function name validation regex - only allows alphanumeric characters, hyphens, and underscores
- const FUNCTION_NAME_REGEX = /^[A-Za-z0-9_-]+$/
- // Define form schema with zod
- const FormSchema = z.object({
- functionName: z
- .string()
- .min(1, 'Function name is required')
- .regex(FUNCTION_NAME_REGEX, 'Only letters, numbers, hyphens, and underscores allowed'),
- })
- // Generate a random function name
- const generateRandomFunctionName = () => {
- const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
- const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
- return `${adjective}-${noun}`
- }
- // Convert invalid function name to valid one
- const sanitizeFunctionName = (name: string): string => {
- // Replace invalid characters with hyphens
- return name.replace(/[^A-Za-z0-9_-]/g, '-')
- }
- // Type for the form values
- type FormValues = z.infer<typeof FormSchema>
- const INITIAL_FILES: FileData[] = [
- {
- id: 1,
- name: 'index.ts',
- content: EDGE_FUNCTION_TEMPLATES[0].content,
- state: 'new',
- },
- ]
- const NewFunctionPage = () => {
- const router = useRouter()
- const { ref, template } = useParams()
- const { data: project } = useSelectedProjectQuery()
- const { data: org } = useSelectedOrganizationQuery()
- const snap = useAiAssistantStateSnapshot()
- const { mutate: sendEvent } = useSendEventMutation()
- const showStripeExample = useIsFeatureEnabled('edge_functions:show_stripe_example')
- const { openSidebar } = useSidebarManagerSnapshot()
- const [files, setFiles] = useState<FileData[]>(INITIAL_FILES)
- const [selectedFileId, setSelectedFileId] = useState<number>(INITIAL_FILES[0].id)
- const [open, setOpen] = useState(false)
- const templatesListboxId = useId()
- const [isPreviewingTemplate, setIsPreviewingTemplate] = useState(false)
- const [savedCode, setSavedCode] = useState<string>('')
- const templates = useMemo(() => {
- if (showStripeExample) {
- return EDGE_FUNCTION_TEMPLATES
- }
- // Filter out Stripe template
- return EDGE_FUNCTION_TEMPLATES.filter((template) => template.value !== 'stripe-webhook')
- }, [showStripeExample])
- const form = useForm<FormValues>({
- resolver: zodResolver(FormSchema as any),
- defaultValues: {
- functionName: generateRandomFunctionName(),
- },
- })
- const {
- mutate: deployFunction,
- isPending: isDeploying,
- isSuccess: hasDeployed,
- } = useEdgeFunctionDeployMutation({
- // [Joshen] To investigate: For some reason, the invalidation for list of edge functions isn't triggering
- onSuccess: () => {
- toast.success('Successfully deployed edge function')
- const functionName = form.getValues('functionName')
- // Allow the mutation state (isSuccess) to propagate before navigating
- // to prevent unnecessary dialog about unsaved changes
- setTimeout(() => {
- if (ref && functionName) {
- router.push(`/project/${ref}/functions/${functionName}/details`)
- }
- }, 150)
- },
- })
- const onSubmit = (values: FormValues) => {
- if (isDeploying || !ref) return
- deployFunction({
- projectRef: ref,
- slug: values.functionName,
- metadata: { name: values.functionName, verify_jwt: true },
- files: files.map(({ name, content }) => ({ name, content })),
- })
- sendEvent({
- action: 'edge_function_deploy_button_clicked',
- properties: { origin: 'functions_editor' },
- groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
- })
- }
- const handleChat = () => {
- const selectedFile = files.find((f) => f.id === selectedFileId)
- openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
- snap.newChat({
- name: 'Explain edge function',
- sqlSnippets: [selectedFile?.content ?? ''],
- initialInput: 'Help me understand and improve this edge function...',
- suggestions: {
- title:
- 'I can help you understand and improve your edge function. Here are a few example prompts to get you started:',
- prompts: [
- {
- label: 'Explain Function',
- description: 'Explain what this function does...',
- },
- {
- label: 'Optimize Function',
- description: 'Help me optimize this function...',
- },
- {
- label: 'Add Features',
- description: 'Show me how to add more features...',
- },
- {
- label: 'Error Handling',
- description: 'Help me handle errors better...',
- },
- ],
- },
- })
- sendEvent({
- action: 'edge_function_ai_assistant_button_clicked',
- properties: { origin: 'functions_editor_chat' },
- groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
- })
- }
- const onSelectTemplate = (templateValue: string) => {
- const template = EDGE_FUNCTION_TEMPLATES.find((t) => t.value === templateValue)
- if (template) {
- setFiles((prev) =>
- prev.map((file) =>
- file.id === selectedFileId ? { ...file, content: template.content } : file
- )
- )
- setOpen(false)
- sendEvent({
- action: 'edge_function_template_clicked',
- properties: { templateName: template.name, origin: 'editor_page' },
- groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
- })
- }
- setIsPreviewingTemplate(false)
- }
- const handleTemplateMouseEnter = (content: string) => {
- if (!isPreviewingTemplate) {
- const selectedFile = files.find((f) => f.id === selectedFileId) ?? files[0]
- setSavedCode(selectedFile.content)
- }
- setIsPreviewingTemplate(true)
- setFiles((prev) => prev.map((f) => (f.id === selectedFileId ? { ...f, content } : f)))
- }
- const handleTemplateMouseLeave = () => {
- if (isPreviewingTemplate) {
- setIsPreviewingTemplate(false)
- setFiles((prev) =>
- prev.map((f) => (f.id === selectedFileId ? { ...f, content: savedCode } : f))
- )
- }
- }
- // Try to sanitize function name when it's invalid
- const handleDeploy = () => {
- const currentName = form.getValues('functionName')
- const isValid = FUNCTION_NAME_REGEX.test(currentName)
- if (!isValid && currentName) {
- const sanitizedName = sanitizeFunctionName(currentName)
- form.setValue('functionName', sanitizedName, { shouldValidate: true })
- }
- form.handleSubmit(onSubmit)()
- }
- useEffect(() => {
- if (template) {
- const templateMeta = EDGE_FUNCTION_TEMPLATES.find((x) => x.value === template)
- if (templateMeta) {
- form.reset({ functionName: template })
- setSelectedFileId(1)
- setFiles([
- {
- id: 1,
- name: 'index.ts',
- content: templateMeta.content,
- state: 'new',
- },
- ])
- }
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [template])
- const hasUnsavedChanges = useMemo(() => !isEqual(INITIAL_FILES, files), [files])
- return (
- <PageLayout
- size="full"
- isCompact
- title="Create new edge function"
- breadcrumbs={[
- {
- label: 'Edge Functions',
- href: `/project/${ref}/functions`,
- },
- ]}
- primaryActions={
- <>
- <Popover open={open} onOpenChange={setOpen}>
- <PopoverTrigger asChild>
- <Button
- size="tiny"
- type="default"
- role="combobox"
- aria-expanded={open}
- aria-controls={templatesListboxId}
- icon={<Book size={14} />}
- >
- Templates
- </Button>
- </PopoverTrigger>
- <PopoverContent id={templatesListboxId} className="w-[300px] p-0" align="end">
- <Command>
- <CommandInput placeholder="Search templates..." />
- <CommandList>
- <CommandEmpty>No templates found.</CommandEmpty>
- <CommandGroup>
- {templates.map((template) => (
- <CommandItem
- key={template.value}
- value={template.value}
- onSelect={onSelectTemplate}
- onMouseEnter={() => handleTemplateMouseEnter(template.content)}
- onMouseLeave={handleTemplateMouseLeave}
- className="cursor-pointer"
- >
- <div className="flex flex-col gap-1">
- <div className="flex items-center">
- <Check
- className={cn(
- 'mr-2 h-4 w-4',
- files.some((f) => f.content === template.content)
- ? 'opacity-100'
- : 'opacity-0'
- )}
- />
- <span className="text-foreground">{template.name}</span>
- </div>
- <span className="text-xs text-foreground-light pl-6">
- {template.description}
- </span>
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- <Button
- size="tiny"
- type="default"
- onClick={handleChat}
- icon={<AiIconAnimation size={16} />}
- >
- Chat
- </Button>
- </>
- }
- >
- <FileExplorerAndEditor
- files={files}
- onFilesChange={setFiles}
- aiEndpoint={`${BASE_PATH}/api/ai/code/complete`}
- aiMetadata={{
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- orgSlug: org?.slug,
- }}
- selectedFileId={selectedFileId}
- setSelectedFileId={setSelectedFileId}
- />
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex items-center bg-background-muted justify-end p-4 border-t bg-surface-100 gap-3"
- >
- <div className="flex items-center gap-3">
- <Label htmlFor="functionName">Function name</Label>
- <FormField
- control={form.control}
- name="functionName"
- render={({ field }) => (
- <FormItem className="flex flex-col gap-0 m-0">
- <div className="flex items-center">
- <FormControl>
- <Input
- id="functionName"
- type="text"
- size={'large'}
- placeholder="Give your function a name..."
- className="w-[250px]"
- {...field}
- />
- </FormControl>
- {form.formState.errors.functionName && (
- <Tooltip>
- <TooltipTrigger>
- <AlertCircle className="w-4 h-4 text-destructive ml-2" />
- </TooltipTrigger>
- <TooltipContent>
- {form.formState.errors.functionName.message}
- </TooltipContent>
- </Tooltip>
- )}
- </div>
- </FormItem>
- )}
- />
- </div>
- <Button
- loading={isDeploying}
- size="medium"
- disabled={files.length === 0 || isDeploying}
- onClick={handleDeploy}
- >
- Deploy function
- </Button>
- </form>
- </Form>
- <PreventNavigationOnUnsavedChanges hasChanges={hasUnsavedChanges && !hasDeployed} />
- </PageLayout>
- )
- }
- NewFunctionPage.getLayout = (page: React.ReactNode) => {
- return (
- <DefaultLayout>
- <EdgeFunctionsLayout title="New">{page}</EdgeFunctionsLayout>
- </DefaultLayout>
- )
- }
- export default NewFunctionPage
|